深入理解 Java 虚拟机(内存篇)

Java 内存区域

概述

对于 C 和 C++ 程序员来说,他们即拥有每一个对象的“所有权”,同时也担负每一个对象生命周期的维护责任。

而 Java 程序员则是在虚拟机自动内存管理机制的帮助下,不用操心对象内存的释放。但是也正由于这个帮助,一旦程序出现内存泄漏和溢出问题的时候,由于不够了解虚拟机运行机制,而对问题无从下手。

运行时数据区域

上图是 Java 虚拟机运行时数据区的结构图

PC Register

当一个 Java 线程启动的时候,就会产生一个程序计数器( Program Counter Register ),它是一块较小的内存空间。

当线程正在执行的是一个 Java方法的时候,它记录的是正在执行的虚拟机字节码指令的地址,如果执行的是 Native 方法,就为空。

该区域是唯一个在 Java 虚拟机中不存在 OOM 的区域。

JVM Stack

与 PC Register 一样,JVM Stack 也是线程私有的。

其描述的是 Java 方法的内存模型。

在方法执行的同时会创建一个栈帧( Stack Frame ) ,用于存储局部变量表,操作数栈等信息。(之后会详说)

Native Stack

与 JVM Stack 非常相似,只不过一个是关于 Java 方法的,一个是关于 Native 方法的。

Heap

对许多应用来说, Java Heap 是 JVM 所管理的内存中最大的一块。其资源也是被所有线程所共享的。

该区域的唯一目的就是存放内存对象实例。它也是垃圾收集器主要管理的区域。(也有称其为“ GC 堆”)

Method Area

也是被线程共享的区域,主要用来存储已被虚拟机加载过的类的信息,常量,静态变量等。

JVM 规范中称它为 Heap 的一个逻辑部分。但它还有一个别名叫做 Non-Heap。

也有部分 HotSpot 虚拟机开发者称其为 Permanent Generation(永久代),因为 HotSpot 虚拟机的设计团队将 GC 分代收集机制应用于方法区,将它看做是永久代处理。

Runtime Constant Pool

运行时常量池是方法区的一部分,用于存放编译期生成的各种字面量和符号用引用。

Direct Memory

直接内存虽然不是 JVM 运行时数据区的一部分,但是却被频繁使用。

在 JDK 1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于 Channel 与 Buffer 的 I/O 方式,它可以使用 Native 直接分配堆外内存,然后用 Java Heap 中的 DirectByteBuffer 对象作为内存引用进行操作。避免在 Java Heap 和 Native Heap 来回复制数据,提高了不少性能。

HotSpot 虚拟机对象

对象的创建

当遇到一条 New 指令的时候,检查该类是否已经被加载解析,初始化。如果没有先执行类的加载过程,关于类的加载之后再详说。

加载检查之后就是分配内存空间。

关于内存的分配根据收集器算法的不同而采用不同的分配方式:

  • 例如 Serial 和 ParNew 等带 Compact 过程的的收集器使用的分配算法是指针碰撞。(因为采用压缩算法,内存相对是平整的)
  • 使用 CMS 这种基于 Mark-Sweep 算法的收集器采用的是空间列表。(因为标记清除容易产生内存碎片化,需要用列表来记录可用内存指针的位置)

考虑到并发情况下操作内存指针的安全性问题,对分配内存又进行了同步处理:

  • 采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • 一开始就给不同线程分配一块叫做本地线程分配缓冲(Thread Local Allocatiuon Buffer,TLAB)的区域当做缓存,只有当它用完了,重新分配的时候才需要同步锁定。

对象的内存布局

对象布局主要由三个部分组成:

对象头主要由两个部分组成:

  1. 第一部分 官方称之为 Mack Word ,主要存储哈希码,GC 分代年龄,锁状态的标志等信息。

  2. 第二分就是存储类型指针了。也就是指向对象的类元数据指针。

Insatance Data

该部分存储的是对象真正的有效信息,存储的是代码中定义的各种类型的字段。不论是父类继承,还是子类定义的信息都会被记录。

Padding

由于虚拟机的管理机制要求,对象的起始地址必须是 8 个字节的整数倍,所以该部分就是用来填补空缺的。

对象的访问定位

关于对象的访问,许多人都知道,是通过在 Stack 上的一个 reference 数据来操作 Heap 上的具体对象,但是是通过什么方式访问却没有指定。

目前主流的有两种方式,句柄直接指针

上图已经表示的非常清楚了,使用该方式的访问,Java Heap 中会划分出一个句柄池,优点很明显,当对象改变时,Java Stack 中的 reference 是不改变的。移动的是句柄中的实例数据指针。

  • 直接指针

很明显 reference 直接指向对象实例数据,优势就是速度更快。( HotSpot 使用这种方式)

Java 垃圾收集器

关于对象存活的判断

引用计数算法

给对象添加一个引用计数器,被引用加 1 ,引用失效减 1 。为 0 就说明不再被使用。

该算法的优势很明显:实现简单,判定效率也很高。

但是缺点也明显:很难解决对象之间相互循环引用的问题。这也是 JVM 没有选用该算法的最主要原因。

可达性分析算法

通过一系列被称为 GC Root 的对象作为起点,从这些节点向下搜索,没有被搜索到的对象都被当做垃圾。

可以被当成是 GC Root 对象的,在 Java 中有:

  • JVM Stack 中引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中 Native 方法引用的对象。

垃圾收集算法

标记-清除算法

先标记无用对象,在清除。是最基础算法,之后的算法都是根据它来改进。

不足有两点:

  1. 标记和清除的效率都不高。
  2. 清除之后会导致内存的碎片化。

复制算法

将内存一分为二,每次用其中一块。当一块内存用完的时候,就将存活的对象复制到另一块新的空的内存中,然后这边一次性全部清空就好了。简单高效。

缺点很明显,就是只有一半的内存能用。

标记-整理算法

可以说是前两种算法的综合,标记阶段和“标记-清除”算法一样,清除阶段先是将存活对象移到一边,然后直接清除没被引用对象。

解决了碎片化和清除效率低的问题。

分代收集算法

当前主流算法,是前面几种算法的综合。

根据对象存活的周期(每 GC 一次算一个周期),将内存分为新生代和老年代。

在新生代由于对象一般都是朝生夕死,而且占用内存量相对小,所以使用复制算法。

老年代存活率高,使用”标记-清理”或者“标记-整理”算法。